1"use client";
2
3
20import { useMemo, useState } from "react";
21import { useParams, useRouter } from "next/navigation";
22import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
23import { Copy, Eye, FilePlus2, FileText, Loader2, Lock, Search } from "lucide-react";
24import { templateApi } from "@/lib/api";
25import { toast } from "@/stores/ui-store";
26import { useWorkspace } from "@/hooks/use-workspace";
27import { TemplatePreviewDialog } from "@/components/dialogs/template-preview-dialog";
28import { TemplateVariableDialog } from "@/components/dialogs/template-variable-dialog";
29import { TemplateInfo } from "@/lib/types";
30
31export default function TemplatesPage() {
32 const params = useParams<{ workspace: string }>();
33 const slugOrId = params?.workspace ?? "";
34 const workspaceSlug = params?.workspace ?? "default";
35 const router = useRouter();
36 const { data: workspace } = useWorkspace(slugOrId);
37 const qc = useQueryClient();
38
39
40
41
42
43 const [search, setSearch] = useState("");
44 const [category, setCategory] = useState("");
45 const filtersActive = search.trim() !== "" || category !== "";
46
47 const all = useQuery({
48 queryKey: ["workspaces", workspace?.id ?? slugOrId, "templates", "all"],
49 queryFn: () => templateApi.list(workspace?.id ?? slugOrId),
50 enabled: !!workspace?.id,
51 retry: false
52 });
53
54 const filtered = useQuery({
55 queryKey: ["workspaces", workspace?.id ?? slugOrId, "templates", { search: search.trim(), category }],
56 queryFn: () =>
57 templateApi.list(workspace?.id ?? slugOrId, {
58 ...(search.trim() ? { search: search.trim() } : {}),
59 ...(category ? { category } : {})
60 }),
61 enabled: !!workspace?.id && filtersActive,
62 retry: false
63 });
64
65 const source = filtersActive ? filtered : all;
66 const items = source.data ?? [];
67 const isLoading = source.isLoading;
68 const isError = source.isError;
69
70 const categories = useMemo(
71 () =>
72 [...new Set((all.data ?? []).map((t) => t.category).filter((c): c is string => Boolean(c)))].sort(),
73 [all.data]
74 );
75
76 const [applyingId, setApplyingId] = useState<string | null>(null);
77
78 const [varsTemplate, setVarsTemplate] = useState<TemplateInfo | null>(null);
79
80 const [previewTemplate, setPreviewTemplate] = useState<TemplateInfo | null>(null);
81 const apply = useMutation({
82 mutationFn: (t: TemplateInfo) =>
83 templateApi.apply(t.id, { workspaceId: workspace?.id ?? "", parentId: null, title: t.name }),
84 onMutate: (t) => setApplyingId(t.id),
85 onSuccess: (page, t) => {
86 setApplyingId(null);
87 toast("success", `Template “${t.name}” applied`, "Opening the new page…");
88 qc.invalidateQueries({ queryKey: ["workspaces", workspace?.id, "templates"] });
89 qc.invalidateQueries({ queryKey: ["workspaces", workspace?.id, "page-tree"] });
90 router.push(`/w/${workspaceSlug}/page/${page.id}`);
91 },
92 onError: (err) => {
93 setApplyingId(null);
94 toast("error", "Could not apply template", err instanceof Error ? err.message : undefined);
95 }
96 });
97
98
99
100 const hasVariables = (t: TemplateInfo): boolean =>
101 Array.isArray(t.variables) && t.variables.length > 0;
102
103 const isOwnerOrAdmin = ["owner", "admin"].includes((workspace?.role ?? "").toLowerCase());
104
105 return (
106 <>
107 <div className="mx-auto max-w-2xl px-6 py-8">
108 <h1 className="mb-1 flex items-center gap-2 text-xl font-semibold">
109 <FilePlus2 className="size-5 text-muted-foreground" aria-hidden /> Templates
110 </h1>
111 <p className="mb-4 text-sm text-muted-foreground">
112 Reusable page structures. “Use” duplicates a template into this workspace as a new page.
113 {isOwnerOrAdmin ? "" : " (Only owners and admins can apply templates in this workspace.)"}
114 </p>
115
116 {}
117 <div className="mb-4 flex flex-col gap-2 sm:flex-row" data-testid="template-directory-controls">
118 <div className="relative flex-1">
119 <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
120 <input
121 type="search"
122 value={search}
123 onChange={(e) => setSearch(e.target.value)}
124 placeholder="Search templates…"
125 aria-label="Search templates"
126 data-testid="template-search"
127 className="w-full rounded-md border border-input bg-background py-1.5 pl-8 pr-2 text-sm placeholder:text-muted-foreground"
128 />
129 </div>
130 <select
131 value={category}
132 onChange={(e) => setCategory(e.target.value)}
133 aria-label="Filter by category"
134 data-testid="template-category"
135 className="rounded-md border border-input bg-background px-2 py-1.5 text-sm"
136 >
137 <option value="">All categories</option>
138 {categories.map((c) => (
139 <option key={c} value={c}>{c}</option>
140 ))}
141 </select>
142 {filtersActive && (
143 <button
144 type="button"
145 onClick={() => { setSearch(""); setCategory(""); }}
146 data-testid="template-clear-filters"
147 className="rounded-md border border-border px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent"
148 >
149 Clear
150 </button>
151 )}
152 </div>
153
154 {isLoading && (
155 <p className="inline-flex items-center gap-2 text-sm text-muted-foreground">
156 <Loader2 className="size-4 animate-spin" /> Loading templates…
157 </p>
158 )}
159
160 {isError && (
161 <p className="rounded-md border border-dashed border-border p-4 text-sm text-muted-foreground">
162 Couldn't load templates.
163 </p>
164 )}
165
166 {!isLoading && !isError && items.length === 0 && (
167 <p className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
168 {filtersActive
169 ? "No templates match the current search or category."
170 : "No templates in this workspace yet. Save one by snapshotting a page, or insert a template from the editor (slash menu → “Insert template”)."}
171 </p>
172 )}
173
174 <ul className="space-y-1.5">
175 {items.map((t) => (
176 <li
177 key={t.id}
178 data-testid={`template-item-${t.id}`}
179 className="flex items-center gap-2.5 rounded-md border border-border px-3 py-2"
180 >
181 <span className="shrink-0 text-lg leading-none" aria-hidden>
182 {t.icon || (t.type === "database" ? <FileText className="size-4 text-blue-600" /> : <FileText className="size-4 text-muted-foreground" />)}
183 </span>
184 <div className="min-w-0 flex-1">
185 <p className="flex items-center gap-1.5 truncate text-sm">
186 {t.name || "Untitled"}
187 {t.isBuiltin && (
188 <span className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
189 <Lock className="size-2.5" aria-hidden /> Builtin
190 </span>
191 )}
192 {t.category && (
193 <span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground" data-testid={`template-category-badge-${t.id}`}>
194 {t.category}
195 </span>
196 )}
197 </p>
198 {t.description ? (
199 <p className="truncate text-[11px] text-muted-foreground" data-testid={`template-desc-${t.id}`}>{t.description}</p>
200 ) : (
201 <p className="text-[11px] text-muted-foreground">
202 {t.type === "database" ? "Database template" : "Page template"}
203 </p>
204 )}
205 </div>
206 {}
208 <button
209 type="button"
210 data-testid={`template-preview-${t.id}`}
211 className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
212 onClick={() => setPreviewTemplate(t)}
213 >
214 <Eye className="size-3.5" aria-hidden />
215 Preview
216 </button>
217 {!t.isBuiltin && isOwnerOrAdmin && (
218 <button
219 type="button"
220 data-testid={`template-apply-${t.id}`}
221 className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
222 onClick={() => (hasVariables(t) ? setVarsTemplate(t) : apply.mutate(t))}
223 disabled={applyingId !== null}
224 >
225 {applyingId === t.id ? (
226 <Loader2 className="size-3.5 animate-spin" aria-hidden />
227 ) : (
228 <Copy className="size-3.5" aria-hidden />
229 )}
230 {applyingId === t.id ? "Applying…" : "Use"}
231 </button>
232 )}
233 </li>
234 ))}
235 </ul>
236 </div>
237 {previewTemplate ? (
238 <TemplatePreviewDialog template={previewTemplate} onClose={() => setPreviewTemplate(null)} />
239 ) : null}
240 {varsTemplate && workspace?.id ? (
241 <TemplateVariableDialog
242 template={varsTemplate}
243 workspaceId={workspace.id}
244 workspaceSlug={workspaceSlug}
245 onClose={() => setVarsTemplate(null)}
246 />
247 ) : null}
248 </>
249 );
250}
251